Skip to content

feat(data): an accepted offer on every hired application; seed-count docs read 818 - #64

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-53-offers-on-hired
Sep 7, 2026
Merged

feat(data): an accepted offer on every hired application; seed-count docs read 818#64
os-sam merged 2 commits into
mainfrom
claude/issue-53-offers-on-hired

Conversation

@claude

@claude claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Closes #53
Closes #61

The state machine reaches hired only from offer (DESIGN.md §02) and F3 writes the offer row, so a hired application without an offer is a history the app cannot produce — yet on origin/main 0ad34d7 all 14 seeded ats_offer rows sit on offer-stage applications and none of the 9 hired ones has one. This PR gives each hired application one accepted offer, in both locale packs through scopeToDemo(), and moves every seed-count statement to what a boot now logs (818). #61 is folded in: AGENTS.md and the src/data/index.ts comment still said 801 / 794.

Sizing decision: 14 → 23, not a re-balance

Every offer-stage application needs its offer for the same state-machine reason the hired ones do (F3 produced it), so the 14 existing rows cannot be redistributed; a re-balance inside 14 would mean moving applications between stages, which moves the 88 / 46 / 28 / 14 / 9 funnel that the release screenshots and docs/screenshots/README.md show. Adding 9 rows keeps every application count where it was — verified below, not assumed.

What changed

File Change
src/data/shared/pipeline.ts OfferStatus gains accepted; 9 accepted rows on a000 a053 a056 a060 a099 a100 a121 a163 a164 (the nine hired applications), table kept sorted by application index; the header invariant rewritten to what is now true (23 · 14 on offer-stage + one accepted per hired)
src/data/shared/build.ts buildOffers: accepted rows carry approved_by (they passed approval); start_date / expires_at go through a signed dayOffset (daysFromNow(n) or daysAgo(-n)) because an offer accepted 33 days ago has a start date and an expiry already in the past
src/data/demo-en/offer.seed.ts · src/data/demo-zh/offer.seed.ts seed comments 14 → 23
src/data/index.ts 794 → 811 ATS/identity rows (+ 7 logins = 818) — #61
AGENTS.md 801 → 818, twice — #61
README.md 809 → 818 in the three seed-size statements; the isolation sentence: Quillstone 2 → 3 offers, Harborline 5 / 31 / 2 / 3 / 25 / 31 / 4 / 3 / 2
DESIGN.md §06 ats_offer row 14 → 23 (14 on offer-stage applications, 3 pending_approval · 9 accepted, one per hired application) — §06 only, §01–§03 untouched
docs/screenshots/README.md one paragraph: the seed grew 809 → 818 by offers only; no shot reads ats_offer; the photographed stage counts did not move
CONTRIBUTING.md the example measured claim reads 3 offers so it stays a true sentence

Not touched (⛔ per the card): src/security/, src/objects/, src/hooks/, src/flows/, src/views/, src/apps/, src/dashboards/, src/datasets/. No new label, option or field, so --i18n-strict had nothing new to check. No dependency change.

Gates — pnpm validate && pnpm lint && pnpm typecheck on 54a442c

Run through os-verify-lock.sh (exit codes captured before any pipe; the lock prints the verdict line):

=== pnpm validate exit=0 ===
  Data: 12 Objects  146 Fields
  UI: 1 Apps  10 Views  3 Dashboards  4 Actions
  Logic: 6 Flows
  Security: 5 Positions  5 Permissions
  Runtime: 2 plugins
  ⚠ flow "employer_verification" · node "ops_review": every approver on this node routes to a group … (3 pre-existing approval-approvers-may-resolve-empty warnings, unchanged)
os-verify-lock: VERDICT command-exit 0 · held the lock 4s · waited 0s

=== pnpm lint exit=0 ===
  Suggestions (3)  … approval-approvers-may-resolve-empty ×3 (pre-existing, unchanged)
  3 suggestion(s) (858ms)
  platform built-ins: 773 i18n issue(s) hidden — rerun with --include-platform to audit them
os-verify-lock: VERDICT command-exit 0 · held the lock 4s · waited 0s

=== pnpm typecheck exit=0 ===
> ats@0.1.0 typecheck /home/user/ats-issue-53
> tsc --noEmit
os-verify-lock: VERDICT command-exit 0 · held the lock 7s · waited 0s

CI (.github/workflows/ci.yml) runs the same three on this PR.

Artifact join — npx objectstack build, dist/objectstack.json

The reproduce one-liner from #53, plus the hired ⟷ offer join, before and after:

0ad34d7 (origin/main) 54a442c (this PR)
Counter(stage of offer.application) Counter({'offer': 14}) Counter({'offer': 14, 'hired': 9})
hired applications with an offer 0 of 9 9 of 9
ats_offer rows · by status 14 · sent 5 · pending_approval 3 · approved 3 · draft 2 · declined 1 23 · accepted 9 · sent 5 · pending_approval 3 · approved 3 · draft 2 · declined 1
status × stage all 14 on offer every accepted on a hired application: true · every non-accepted on an offer application: true
ats_application by stage applied 88 · screening 46 · interview 28 · offer 14 · hired 9 · rejected 15 identical
seed rows · datasets 809 · 17 818 · 17
offers per employer (Quillstone / Harborline) 2 / 2 3 / 4

Runtime read-back — memory driver, port 4403

OS_PLATFORM_OWNER_EMAIL=admin@objectos.ai npx objectstack dev --fresh --database-driver memory -p 4403 --log-level info, counted only after the seeder line:

before  INFO [ats] demo seed enabled: … loading 17 datasets, 809 rows, 7 demo logins (README.md)
before  INFO [Seeder] Seed loading complete {"inserted":809,"updated":0,"skipped":0,"errored":0}
after   INFO [ats] demo seed enabled: … loading 17 datasets, 818 rows, 7 demo logins (README.md)
after   INFO [Seeder] Seed loading complete {"inserted":818,"updated":0,"skipped":0,"errored":0}

Per persona, GET /api/v1/data/OBJECT?$top=1&$count=true (total) after POST /api/v1/auth/sign-in/email:

persona object before after
admin@platform.example ats_offer 14 (approved 3 · declined 1 · draft 2 · pending_approval 3 · sent 5) 23 (accepted 9 · approved 3 · declined 1 · draft 2 · pending_approval 3 · sent 5)
admin@quillstone.example jobs / applications / offers / members / inquiries 5 / 27 / 2 / 3 / 2 · offers: pending_approval 1 · sent 1 5 / 27 / 3 / 3 / 2 · offers: accepted 1 · pending_approval 1 · sent 1
admin@harborline.example jobs / applications / offers / members / inquiries 5 / 31 / 2 / 3 / 2 · offers: approved 1 · draft 1 5 / 31 / 4 / 3 / 2 · offers: accepted 2 · approved 1 · draft 1

The new rows as the employer persona reads them (GET /api/v1/data/ats_offer?$filter={"status":"accepted"}), CEL dates resolved at seed time:

## quillstone_admin: accepted offers total=1
   {"display_name": "Offer · Avery Lindqvist → Robotics Software Engineer", "status": "accepted", "start_date": "2026-09-18", "expires_at": "2026-09-02", "approved_by": "usr_ats_quillstone_admin", "salary": 150000, "salary_period": "yearly"}
## harborline_admin: accepted offers total=2
   {"display_name": "Offer · Elena Petrova → Welder", "status": "accepted", "start_date": "2026-09-03", "expires_at": "2026-08-13", "approved_by": "usr_ats_harborline_admin", "salary": 30, "salary_period": "hourly"}
   {"display_name": "Offer · Karin Lindgren → Welder", "status": "accepted", "start_date": "2026-09-06", "expires_at": "2026-08-21", "approved_by": "usr_ats_harborline_admin", "salary": 30, "salary_period": "hourly"}
## seeker candidate01: accepted offers total=1
   {"display_name": "Offer · Avery Lindqvist → Robotics Software Engineer", "status": "accepted", "start_date": "2026-09-18", "expires_at": "2026-09-02", "approved_by": "usr_ats_quillstone_admin", "salary": 150000, "salary_period": "yearly"}

Platform personas read 23 here because this is the memory driver; on sqlite ats_offer reads 0 to them (#39, expected, not touched).

Employer dashboard (ats_employer_hiring) before / after — unchanged, as it must be

None of its four widgets reads ats_offer (job, application, interview datasets only), so the numbers cannot move; measured anyway, both employer personas, POST /api/v1/analytics/dataset/query with the widget's own filter as selection.runtimeFilter, cross-checked against a REST $filter count (compiled SQL in the probe output shows the filter and the row-level scope both applied):

persona open_jobs awaiting_action interviews_this_week pipeline_by_stage (applied/screening/interview/offer/hired/rejected)
Quillstone · before 3 18 6 10 / 8 / 5 / 2 / 1 / 1
Quillstone · after 3 18 6 10 / 8 / 5 / 2 / 1 / 1
Harborline · before 3 21 1 14 / 7 / 3 / 2 / 2 / 3
Harborline · after 3 21 1 14 / 7 / 3 / 2 / 2 / 3

REST cross-check column agreed with every analytics number in both runs. (Measurement note for whoever probes this next: a filter placed at body.filter, selection.filter, filters or where is accepted with HTTP 200 and silently ignored — the compiled SQL carries only the row-level scope, and Quillstone's open_jobs reads 5; the key the analytics service reads is selection.runtimeFilter. The Console uses that key, which is why the screenshots show 3 / 18 / 6.)

Doc consistency — every seed-count statement now agrees

Where Before After
boot banner (demo-seed-gate.ts, computed) 17 datasets, 809 rows, 7 demo logins 17 datasets, 818 rows, 7 demo logins
README.md line 18 / boot table ×2 809 ×3 818 ×3
README.md isolation sentence Quillstone 2 offers · Harborline 5 / 31 / 2 / 3 / 2 3 offers · 5 / 31 / 4 / 3 / 2 (matches the read-back above)
AGENTS.md ×2 (#61) 801 818
src/data/index.ts (#61) 794 ATS/identity rows + 7 logins (= 801) 811 + 7 (= 818)
DESIGN.md §06 ats_offer 14 · 3 pending_approval 23 · 14 on offer-stage (3 pending_approval) · 9 accepted, one per hired
src/data/shared/pipeline.ts header invariant 14 · one per offer-stage application 23 · one per offer-stage (14) + one accepted per hired (9)
docs/screenshots/README.md shot-time 809 809 kept as the shot-time fact + a paragraph saying 818 today and why nothing photographed moved
CONTRIBUTING.md example claim 2 offers 3 offers

Screenshots — still valid, by count

Stage counts did not move: 88 / 46 / 28 / 14 / 9 marketplace-wide (artifact and the funnel dataset both), Quillstone 10 / 8 / 5 / 2 / 1 (+1 rejected) on the kanban and the Hiring Overview bar. Platform Overview reads employers / jobs / applications / candidates — none changed. The interview calendar reads ats_interview (40, unchanged); Find Jobs reads ats_job (22 published, unchanged). No shot reads ats_offer, so none was reshot.

Left as found, on purpose

🤖 Generated with Claude Code

https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG


Generated by Claude Code

os-sam and others added 2 commits September 7, 2026 17:50
…s read 818

The state machine reaches `hired` only from `offer`, and F3 writes the offer
row, so a hired application without an offer is a history the app cannot
produce — yet all 14 seeded offers sat on offer-stage applications and none
of the 9 hired ones had one. Add one `accepted` offer per hired application
(9 rows, 14 -> 23) in the skeleton both locale packs render through
scopeToDemo(); the accepted rows carry approved_by and date offsets relative
to the hire date, which is why the offer builder now takes a signed offset.

Sizing: 14 -> 23 rather than re-balancing inside 14, because every
offer-stage application needs its offer too and moving applications would
move the 88/46/28/14/9 funnel that the release screenshots show.

Seed-count statements move 809 -> 818 (README x3, screenshots README) and
the stale 801/794 in AGENTS.md and src/data/index.ts are corrected at the
same time; DESIGN.md §06's ats_offer row, the seed comments and the
CONTRIBUTING example follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
The employer-hiring dashboard's comment justified leaving out the
"median days to offer" tile partly on the grounds that "the demo seed
has no offer on any hired application (its 14 offers all sit on
`offer`-stage applications), so the tile would read empty anyway".
This PR makes that false. The tile stays out for the two reasons that
still hold — no median measure in the semantic layer, and the duration
needs a stored column — so the bullet is rewritten to say that, rather
than left asserting something the same PR disproves.

Comment only; no dashboard, dataset or widget behaviour changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG

os-sam commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Review — verified independently, then extended by one commit

Re-measured from a clean worktree at 54a442c, not from the report. Everything the PR claims held. One follow-up commit added (b209975, below).

The core claim, from the artifact I built myself

npx objectstack build, then joined ats_offer.applicationats_application.display_name in dist/objectstack.json:

Check Result
hired applications 9
hired with exactly one accepted offer 9 of 9
hired with no offer none
accepted offers on a non-hired application none
non-accepted offers not on an offer-stage application none
applications carrying more than one offer none
duplicate offer display_name none
offers whose application does not resolve 0
stage counts applied 88 · screening 46 · interview 28 · offer 14 · hired 9 · rejected 15
total seed rows 818 (= 811 + the 7 sys_account logins, matching src/data/index.ts)

The invariant is now two-sided and both sides hold: every hired application has an accepted offer, and no accepted offer sits anywhere else.

The locale packs are not asserted-equal, they were both built

OS_SEED_LOCALE=zh npx objectstack build and the same join: 818 rows, 23 offers (accepted 9 · sent 5 · approved 3 · pending_approval 3 · draft 2 · declined 1), same stage counts, 9 of 9 hired with exactly one accepted offer, approved_by drawn from six different employer admins. scopeToDemo() carries the change through both packs, as claimed.

Dates — checked for coherence, not just for presence

An accepted offer whose expiry is in the past is only defensible if it expired after the acceptance. Resolving every CEL offset against its application's last_activity_at (the hire date):

  • no accepted offer has expires_at earlier than the hire date — the offer never expired before it was accepted;
  • no accepted offer has start_date earlier than the hire date;
  • every accepted offer carries approved_by, and no draft/pending_approval offer does;
  • the nine // hired N days ago comments each match that row's activityDaysAgo exactly.

So the negative offsets are a story the state machine can produce, not an artifact of arithmetic.

Runtime, on a boot of my own (memory driver, port 4501)

INFO [ats] demo seed enabled: … loading 17 datasets, 818 rows, 7 demo logins
INFO [Seeder] Seed loading complete {"inserted":818,"updated":0,"skipped":0,"errored":0}

errored: 0 is the load-bearing line here: ats_offer carries a state_machine validation in which accepted is terminal, so a seed inserting at accepted was the one thing the artifact could not rule out. It inserts.

Through REST, per persona ($count=true, and I asserted hasMore == false and len(records) == total before counting — the earlier draft of this probe read the envelope wrong and reported 0 rows against a correct total):

persona ats_offer by status
admin@platform.example 23 accepted 9 · sent 5 · approved 3 · pending_approval 3 · draft 2 · declined 1
admin@quillstone.example 3 accepted 1 · pending_approval 1 · sent 1
admin@harborline.example 4 accepted 2 · approved 1 · draft 1
candidate01@mail.example 1 accepted 1 (her own)

Quillstone 5 / 27 / 3 / 3 / 2 and Harborline 5 / 31 / 4 / 3 / 2 — the README isolation sentence this PR rewrote is true as written. Each accepted offer's approved_by is that employer's own admin (usr_ats_harborline_admin for the Harborline rows, usr_ats_summitridge_admin for Summitridge, and so on) and its employer_org matches — the new rows did not cross a tenancy boundary.

The hired ⟷ offer join re-run through RLS rather than over the artifact gives the same 9 of 9, with 0 unresolved lookups.

The screenshots claim — checked at the source, not by eye

grep -rn offer src/datasets/ returns nothing: no dataset reads ats_offer, so no widget number can move regardless of how many offer rows exist. Combined with the unchanged stage counts, the release shots stay valid without a reshoot. That is a stronger argument than the PR body's, and it happens to agree with it.

One thing the PR left false — fixed in b209975

src/dashboards/employer-hiring.dashboard.ts justified omitting the "median days to offer" tile partly on this bullet:

the demo seed has no offer on any hired application (its 14 offers all sit on offer-stage applications), so the tile would read empty anyway

This PR disproves that sentence. It was correctly left alone by the implementing agent — src/dashboards/ was on the card's ⛔ list and #55 owns that block — but "a later card will fix it" is not a reason to merge a false statement into living source, and the bullet is a justification, not a dated measurement. Rewritten to say the tile now stays out for the two reasons that still hold (no median measure in the semantic layer; the duration needs a stored column), not for want of data. Comment only; pnpm validate && pnpm lint && pnpm typecheck all exit 0 on b209975.

docs/evidence/issue-8/* and docs/backlog/10-seed-data.md keep their 14 / 809 — those are dated records of a measurement, and rewriting them would be the actual error.

Not verified, and saying so

I did not open the Console; the check here is REST and artifact only. Nothing in the change is UI-shaped, and no dashboard reads the object, so I judged a browser pass unnecessary rather than done.

Merging. #55 is unblocked by this, and its "seed an offer on hired applications" scope bullet is now already done — I will trim that card rather than let it re-do the work.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review September 7, 2026 18:09
@os-sam
os-sam merged commit 62e496d into main Sep 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant